Skip to content

feat: record judge scores as gen_ai.evaluation.result - #43

Open
ccschmitz-launchdarkly wants to merge 7 commits into
mainfrom
O11Y-1888-judge-evals
Open

feat: record judge scores as gen_ai.evaluation.result#43
ccschmitz-launchdarkly wants to merge 7 commits into
mainfrom
O11Y-1888-judge-evals

Conversation

@ccschmitz-launchdarkly

@ccschmitz-launchdarkly ccschmitz-launchdarkly commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Judge scores land as a gen_ai.evaluation.result span event (plus mirrored span attributes) on the judge's invoke_agent span, so conversation turn badges can render.

  • with_judge_evaluation holds the judge invoke_agent span open until scoring finishes. execute_and_track returns after the handler has already called span.end(), so without the delay the event would be dropped.
  • Event attributes: gen_ai.evaluation.name and gen_ai.evaluation.score.value, mirrored as span attributes. score.label is not invented, and only a finite number is recorded.
  • Existing track(evaluation_metric_key) behavior is unchanged.
  • TELEMETRY-CONTRACT.md section 4a documents the event contract; section 2 lists the mirrored root-span attributes.

Mirrors js-ai-sdk#27. Part of O11Y-1888. Split out of the original combined PR so the two halves could be reviewed apart — the conversation-id half is #42, now merged, so this targets main.

Review follow-ups

  • Judge explanation gated on content capture. gen_ai.evaluation.explanation is emitted only when the judge's own handler captures content. ProviderHandler now carries capture_content, set by create_handler alongside provides_for, so the client core can apply the handler's content decision without reaching into its closure. All six handler factories pass it through. Removing it outright would have left the conversation view's badge tooltip and evaluation banner — both of which read this field — permanently blank. The reasoning always reaches the caller in judge_results.
  • float(score) replaced with a non-raising finite-number guard. float() was a new raise site sitting ahead of the existing client.track(evaluation_metric_key, …) call, so a judge returning "0.9 (high)" silently killed the metric this PR claims is unchanged — and in run_judge it escaped uncaught, breaking that function's documented "returns None" contract.
  • End time frozen at the handler's end() call, so the deferred end no longer stamps release time and inflate the span by the tracking and parsing work in between.
  • Detach even if the deferred end raises, so a failing exporter can't leave the judge capture bound in the task's context.

Verified in staging

Real judge AI Config (chriss-test-judge, Anthropic / claude-sonnet-4-5) attached to chriss-test-config with sampling_rate: 1, in default / staging. The judge's invoke_agent span carries:

gen_ai.evaluation.name        = chriss-test-judge
gen_ai.evaluation.score.value = 0.3
gen_ai.evaluation.explanation = <judge reasoning, present because capture is on>
gen_ai.conversation.id        = judge-expl-py-4e7728ff
telemetry.sdk.language        = python

with the explanation present because this run had capture_content on; with capture off it is absent. Both directions verified.

Rendered with the judge-score UI change, pinned via frontend-version=f413f8a22:

https://ld-stg.launchdarkly.com/projects/default/ai-configs/chriss-test-judge/monitoring?env=staging&selected-env=staging&chartTypes=Tokens%2CSatisfaction%2CGenerations%2CTime%20to%20generate%2CError%20rate%2CTime%20to%20first%20token%2CCosts%2CEvaluator%20metrics&related_resource=eyJ0eXBlIjoiY29udmVyc2F0aW9uIiwiY29udmVyc2F0aW9uSWQiOiJqdWRnZS1leHBsLXB5LTRlNzcyOGZmIiwidHJhY2VJZCI6ImI0NzYwYThhYjQ2MmI2YzViOTk0MDQ5NzE4Njk1ODI1IiwidGltZXN0YW1wIjoiMjAyNi0wOC0yMFQyMDo1OToxNC4wNzI1MDhaIiwiY2FuR29CYWNrIjp0cnVlfQ%3D%3D&frontend-version=f413f8a22

Without that frontend pin the score does not render: the conversation view's existing judge readers expect Vega's agent.judge.* shape rather than the semconv gen_ai.evaluation.* emitted here. Tracked in O11Y-1905.

Reviewer notes

The span.end patch is the part worth scrutiny. Nothing breaks today — judges only take the blocking path — but it is the one place the SDK mutates a live span's lifecycle, and span lifecycle is this SDK's worst historical surface. Three reviewers independently suggested the deeper fix: let the judge own its own span rather than mutating a handler-owned one. Deliberately not done here; it's a design decision that deserves its own call.

Found while verifying — not fixed here

Python's BYOC path calls _setup_telemetry(opts.get("sdkKey", "byoc"), …), so a caller who passes a pre-built client via init_client(client=…) without also passing sdkKey gets every span stamped highlight.project_id: "byoc". The ingest cannot resolve that, so all telemetry is silently dropped. TypeScript's BYOC path falls back to process.env.LD_SDK_KEY. Pre-existing and unrelated to this PR; needs its own ticket.

Test plan

  • uv run pytest — 1083 pass, 11 skipped
  • uv run mypy packages/*/src, ruff check, ruff format --check clean
  • Evaluation event lands on the invoke_agent span after the handler has already ended it
  • score.label is not invented; non-numeric scores are rejected without raising
  • End time reflects the handler's end() call, not release (test fails if the freeze is removed)
  • Against staging: judge score on the span, metric track still fires, explanation absent

Note

Overview
Judge scores now land as a gen_ai.evaluation.result span event (plus mirrored attributes) on the judge's invoke_agent span, so conversation-turn score badges can render. Existing track(evaluationMetricKey) is unchanged.

with_judge_evaluation delays span.end() until the score is parsed, because execute_and_track returns after the handler has already ended the span. End time is frozen at the handler's end() so tracking/parsing does not inflate duration. Only a finite numeric score is recorded; junk scores skip the event without raising.

Judge reasoning is not exported unless the judge handler was built with capture_content. ProviderHandler now surfaces that flag so the core can apply the same content gate. Callers still get reasoning in judge_results.

Reviewed by Cursor Bugbot for commit 8a66365. Bugbot is set up for automated code reviews on this repo. Configure here.

Judge scores land as a `gen_ai.evaluation.result` span event (and mirrored
attributes) on the judge `invoke_agent` span, so conversation turn badges can
render. `with_judge_evaluation` holds that span open until scoring finishes —
`execute_and_track` returns after the handler has already called `span.end()`,
so without the delay the event would be dropped.

Existing `track(evaluation_metric_key)` behavior is unchanged.
TELEMETRY-CONTRACT.md section 4a documents the event contract.

Stacked on the conversation-id PR: the two halves share conversation.py and
nothing else, so they review apart and merge together.

O11Y-1888

Co-Authored-By: Claude <noreply@anthropic.com>
Vega and others added 4 commits August 19, 2026 17:09
…Y-1888-judge-evals

# Conflicts:
#	packages/client/tests/test_conversation.py
…nd time

Review follow-ups on the judge half.

- Do not emit `gen_ai.evaluation.explanation`. The judge's reasoning is
  model-generated prose about the user's conversation — content — and AGENTS.md
  gates content attributes behind `capture_content`, a handler-factory option
  this layer never receives. It was exported unconditionally, including for
  callers who left capture off. The reasoning is still returned to the caller
  in `judge_results`; only the telemetry copy is withheld.
- Replace `float(score)` with a non-raising finite-number guard. `float()` was
  a new raise site sitting ahead of the existing
  `client.track(evaluation_metric_key, …)` call, so a judge returning
  "0.9 (high)" silently killed the metric this PR claims is unchanged — and in
  `run_judge` it escaped uncaught, breaking that function's documented
  "returns None" contract.
- Freeze the end time when the handler calls `end()`. Replaying a no-arg
  `end()` at release let the SDK stamp `time_ns()` then, inflating every judge
  span by the tracking and parsing work that runs in between.
- Detach the judge capture even if the deferred end raises.

O11Y-1888

Co-Authored-By: Claude <noreply@anthropic.com>
One run that exercises everything O11Y-1888 touches: three turns bound to a
single conversation id, each with inline judge evaluation. Prints the id so it
can be opened directly in the Conversations view.

It is also the manual check for the content decision — the judge's reasoning is
printed from `judge_results` (the caller's copy) and should appear nowhere in
the exported telemetry.

O11Y-1888

Co-Authored-By: Claude <noreply@anthropic.com>
Base automatically changed from O11Y-1888-emit-conversation-id-and-evaluation to main August 20, 2026 19:45
@ccschmitz-launchdarkly
ccschmitz-launchdarkly marked this pull request as ready for review August 20, 2026 20:40

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 005ce8e. Configure here.

Comment thread examples/conversation.py
print(
f"[judge] {judge_key} score={score} reasoning={reasoning}",
file=sys.stderr,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Judge example reads dicts as objects

Medium Severity

The new conversation example prints judge score and reasoning via getattr on each judge_results value. Inline judges still store plain dicts (score / response keys), so those attributes are missing and the example always prints None even when a judge ran. That makes the O11Y-1888 end-to-end check look like it had no scores.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 005ce8e. Configure here.

The judge's reasoning was removed outright on PII grounds. That was the right
instinct in the wrong shape: the conversation view reads
`gen_ai.evaluation.explanation` for its badge tooltip and evaluation banner, so
dropping it left a field the UI is built to display permanently blank.

Gate it instead, like every other content attribute:

- `ProviderHandler` now carries `capture_content`, set by `create_handler`
  alongside `provides_for`. The client core can apply the handler's own content
  decision to content it writes on the handler's behalf, without reaching into
  the factory's closure.
- All six handler factories pass their flag through.
- `run_judges` / `run_judge` forward the reasoning only when the judge's own
  handler captures content.

Capture on: the explanation reaches the span and the UI renders it.
Capture off: absent, exactly as before this commit — no ungated leak.

O11Y-1888

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant